Skip to content

[local-explorer-ui][miniflare] Improve resending of emails from local explorer - #15567

Open
tpmmorris wants to merge 11 commits into
cloudflare:mainfrom
tpmmorris:tmorris/email-explorer-ui
Open

[local-explorer-ui][miniflare] Improve resending of emails from local explorer #15567
tpmmorris wants to merge 11 commits into
cloudflare:mainfrom
tpmmorris:tmorris/email-explorer-ui

Conversation

@tpmmorris

@tpmmorris tpmmorris commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Updates Emails in Local Explorer

Previously, resending always sent the last successful email without the option to edit the email before sending. The new resending is row-by-row, with buttons available to 'edit and resend' or 'resend immediately' on any captured email. Emails can only be edited if they are provided by the 'Send Test Email' composer, rather than sent via the /cdn-cgi/local/email endpoint, which may have fields which can't be parsed to the composer.

This allows users to quickly amend emails, or send the same email to an adjusted worker.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: Covered in existing documentation.

Devin Review

@changeset-bot

changeset-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2d5b2ab

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@cloudflare/local-explorer-ui Minor
miniflare Minor
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch
@cloudflare/remote-bindings Patch
@cloudflare/runtime-types Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-plugin Patch
wrangler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Sep 8, 2026
@workers-devprod
workers-devprod requested review from a team and penalosa and removed request for a team September 8, 2026 16:05
@workers-devprod

workers-devprod commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/fresh-mails-resend.md: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/e2e/email/email-routing.spec.ts: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/e2e/email/utils.ts: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/e2e/worker-selector.spec.ts: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/tests/utils/email-resend.test.ts: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/components/email/EmailList.tsx: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/components/email/SendTestEmailDialog.tsx: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/components/email/TestEmailDraftsContext.tsx: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/routeTree.gen.ts: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/routes/__root.tsx: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/routes/email.tsx: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/routes/email/routing/$captureId.tsx: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/routes/email/routing/index.tsx: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/routes/email/sending.tsx: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/routes/email/shared/useCursorPaginatedList.ts: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/routes/email/shared/useRoutingEmailActions.ts: [@cloudflare/wrangler]
  • packages/local-explorer-ui/src/utils/email-resend.ts: [@cloudflare/wrangler]
  • packages/miniflare/scripts/email-openapi.ts: [@cloudflare/wrangler]
  • packages/miniflare/scripts/openapi-filter-config.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/email.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/address.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/contracts.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/email-store.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/email-store.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/message-id.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/mime.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/received-capture.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/storage.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/email-contracts.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/explorer.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/types.gen.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/openapi.local.json: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/resources/email.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/route-names.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/local-explorer/email.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/local-explorer/telemetry.spec.ts: [@cloudflare/wrangler]

@pkg-pr-new

pkg-pr-new Bot commented Sep 8, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/autoconfig@15567

@cloudflare/build-output-utils

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/build-output-utils@15567

@cloudflare/codemods

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/codemods@15567

@cloudflare/config

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/config@15567

@cloudflare/containers-shared

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/containers-shared@15567

create-cloudflare

npm i https://pkg.pr.new/cloudflare/workers-sdk/create-cloudflare@15567

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/deploy-helpers@15567

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/kv-asset-handler@15567

miniflare

npm i https://pkg.pr.new/cloudflare/workers-sdk/miniflare@15567

@cloudflare/pages-functions

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/pages-functions@15567

@cloudflare/pages-shared

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/pages-shared@15567

@cloudflare/unenv-preset

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/unenv-preset@15567

@cloudflare/vite-plugin

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/vite-plugin@15567

@cloudflare/vitest-plugin

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/vitest-plugin@15567

@cloudflare/workers-auth

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/workers-auth@15567

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/workers-editor-shared@15567

@cloudflare/workers-utils

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/workers-utils@15567

wrangler

npm i https://pkg.pr.new/cloudflare/workers-sdk/wrangler@15567

commit: 2d5b2ab

devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

@penalosa penalosa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very large PR, which makes it quite hard to review. I wonder if you'd be able to either:

  • split it up into more reviewable chunks
  • go through and do a review yourself, pointing out the bits that you think need special attention from reviewers/explaining the decisions you made along the way

Comment thread packages/miniflare/src/workers/email/mime.ts
devin-ai-integration[bot]

This comment was marked as resolved.

@tpmmorris tpmmorris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is used to update local explorer's email group to make resend row-based, looking up emails in storage to resend the stored data. Implementation ensures local explorer instances running peers on older versions of miniflare can still operate.

Generated files and tests consume approx. +2200/-200 of the diff.

The primary implementation areas are:

  • storage identity (capture ID introduction)
  • server-side resend
  • UI action availability + lifecycle

emptyState: ReactNode;
error: string | null;
getRow: (item: T) => EmailListRow;
getRow: (item: T, index: number) => EmailListRow;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Index is used here to allow a local explorer exposing peers, possibly with older versions of miniflare, to unambiguously render rows when looking up via message id, as lookup is now primarily done with the guaranteed unique capture ID. Peers using an older version of miniflare that does not have capture ID can be inspected, but can't be resent (as of e4fd618).

let angleDepth = 0;
let commentDepth = 0;
let escaped = false;
let groupDepth = 0;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracks whether the parser is inside of an email group to parse it correctly, groups are written as:
ExampleGroup: name@domain.com, anotherName@domain.com;

Comment on lines +34 to +45
interface RoutingEmailActions {
dialogDraft?: TestEmailDraft;
dialogOpen: boolean;
dialogWorker?: string;
editAndResend: (email: RoutingEmail) => Promise<void>;
getRowActionState: (email: RoutingEmail) => EmailRoutingActionState;
handleDialogOpenChange: (open: boolean) => void;
openBlankComposer: () => void;
requestInboxRefresh: (expectedGeneration: number) => void;
resend: (email: RoutingEmail) => Promise<void>;
workerGeneration: number;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Row actions depend on the emails result, if an email is truncated/not sent from the composer, editAndResend is unavailable, but direct resend is.

Comment on lines +1415 to +1417
let lookup: ReceivedCaptureOperationLookup;
try {
lookup = await loadReceivedCaptureForOperation(c, worker, captureId);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resending is server side, looking up a capture in DO storage by its capture ID and worker, and creating a new send with the stored data. Truncated emails are resent as they are stored, so behaviour may not be identical. In the future we can possibly use local file capture for all email storage, making this redundant, but this is out of scope for now.

Comment on lines +67 to +68
captureTruncated: z.boolean().optional(),
capturedPortion: z.boolean().optional(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

captureTruncated describes whether the email was truncated when captured. capturedPortion is inherited, and describes whether this email was truncated at any point in its history, such as a sent email that was truncated and then resent; the resend wasn't directly truncated, so captureTruncated = false, but the original email it was resent using was truncated, so capturedPortion = true.

};

export interface EmailCaptureContext {
origin?: EmailCaptureOrigin;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only composer constructed emails are permitted to be edited and resent. If the email is marked unknown it may contain fields not supported by the composer, so shouldn't be edited in the composer.

bodyRawBase64: string[],
createCaptureId: () => string = () => crypto.randomUUID()
): Promise<string> {
for (let attempt = 0; attempt < 3; attempt++) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attempt up to 3 ID generations. Failure to find a non-colliding UUID here will cause a capture failure, but is practically impossible.

Comment on lines +1447 to +1452
let mime: Uint8Array;
let messageId: string;
try {
const source = base64ToBytes(email.rawBase64);
messageId = synthesizeMessageId(extractAddressFromString(email.from));
mime = setMessageIdHeader(source, messageId);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Injects a new message ID for resent messages to simulate a "new" send occurrence.

Comment on lines 624 to 627
storeSent(email: StoredSendingEmail): void {
this.#insert(
"sent",
messageIdToStorageId(email.messageId),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sending emails still uses messageId for lookup, as this is uniquely synthesised per send. Routing emails now use a capture ID which is generated on capture. This is because in production, message IDs are unique per send, not per receipt, an email delivered to multiple recipients will have the same message ID for each of them, and it is reasonable to adopt similar behaviour here.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

3 participants